查看原文
其他

深度解析射击游戏的「打击感」!Cocos Creator 实现超爽枪弹射击效果

nowpaper COCOS 2022-06-10

射击游戏的射击感/打击感直接关于游戏体验。本次「Star Writer」Nowpaper 将解析《守望先锋》中的枪弹射击实现思路,复刻爽感爆棚的射击效果。


我本人是一个射击游戏爱好者,从最早的《Doom》、 《Quake》,再到后来的《CS》、《荣誉勋章》、《使命召唤》、《守望先锋》,FPS 游戏一直让我痴迷其中。我认为,子弹射击效果绝对是射击游戏体验感的核心,子弹射出和打击到墙面瞬间的细节,虽然不起眼,但绝对是提升游戏品质的关键。


目前我最喜欢《守望先锋》里的射击感、速度感和打击感,今天就让我们在 Cocos Creator 中复刻一下《守望先锋》的枪弹射击效果。当然,《守望先锋》的美术和 TA 肯定不是我能比的,重点是借鉴它的枪弹射击表现的实现思路


注意:做这个效果时使用的是 Cocos Creator 3.3.2,该版本引擎中的代码块在处理模型粒子时不支持跟随节点转动。幸而技术群内的大佬陈炫烨提供了一个正确的代码块给我,我才得以完成,在此特别表示道谢! v3.4 中,粒子特效不能跟着旋转的问题已经解决,新版本中的粒子系统能够让粒子指定参照坐标系,因此不需要替换代码块。


成品效果


成品效果


这是一个模拟的靶场,滑杆调整角度,设置界面可调整子弹速度、偏移、弹容量、重填时间、射速、单次子弹数这六个参数,基本涵盖各种常规的射击枪械。为了演示,枪械方面直接用方块替代,没做太复杂的模型。



在第一人称和第三人称的测试场景中,可以更加清晰地看到实际应用效果:



特效原理



现在拆解一下特效部分。子弹射击效果主要由这几个方面组成:枪口喷射的火焰、子弹飞行的轨迹、击中后的特效,如果有条件的话还需要音效。


枪口喷射的火焰



首先是枪口喷射的火焰,参考一下实际效果(如上图)可以看出,它是由外散火焰和一个散射的外圈组成,并且喷射时候会带上一个光晕。这么来看它至少要由两个粒子系统来表现。使用一个粒子系统来制作喷射火光,参数中的核心数据是 Bursts。这个火光粒子的生命周期实际上很短,因此要用 Bursts 来表现它的短暂张力,后面的所有特效也是同样的处理。


注意:Bursts 模块在 Cocos Creator 3.3.0 中有 bug,不能显示 count 数量,因此需要 v3.3.2 以后的版本才能制作。


具体的参数就不列举了,这是一个非常消耗时间的工作,通过慢速给大家看一下它的具体组成。枪口火焰是一个交叉的面片,给予一个粒子材质随机旋转,并使用贴图动画模块切换纹理;飞溅火焰是一个喇叭型的模型由小变大的动画过程;而光晕则使用了一大号爆发粒子瞬间闪烁造成的视觉效果。



子弹飞行的轨迹


子弹飞行的轨迹相对简单,主要由一个冲击粒子和一个拖尾粒子两个部分组成。冲击粒子由一个喇叭模型表现子弹破空效果,这和刚刚提到的枪火喷射粒子基本一致,只不过它是以循环闪烁的方式表现;拖尾粒子是在 Z 轴上拉长的单个循环粒子,同样也是用 Bursts 产生,用来展示飞行中不稳定光感波动。



击中后的特效


击中墙壁效果,是所有粒子效果中最为复杂的,它由以下几个部分组成:炸裂、火花、烟雾、斑痕、光晕。


炸裂效果是命中时的溅射,使用两个开口模型粒子实现,采用和枪火喷射一样的处理即可,只不过它缩小了一圈。


火花这个是最难的,我使用的是圆锥型喷射模块,随机飞溅出几个粒子,并且还得带有重力的物理特性。除此之外大小也是一个难题,太大显得不真实,太小又看不清楚,调它的时候着实费了不少力气。



烟雾的表现还好,只需要一个简单上升粒子即可,虽然如此但它的数值想调得自然还是比较难。


命中后的斑痕,研究后发现很多游戏表现手法都是双层重叠,命中点一层,扩散点一层,命中点很快消失,扩散点会逐步消失。更细一点的做法是依据物体表面材质,用不同贴图表示斑痕,有的还使用了消解效果的 shader。这方面我不想增加复杂度,因此就不用 shader 了,直接以渐变消失的粒子效果处理。


代码逻辑


在写代码之前,我们先分析一下功能的需求,画一张脑图来表示我们需要什么:



最基础的就是枪和子弹,枪械代码主要的功能是发射子弹,它通过 Prefab 来创建子弹,从发射点发射出去,发射过程需要扳机控制,对应的会产生喷射特效,枪火特效可以重复使用一个粒子特效,不用每次都产生。


如果想做出真实的枪械射击感,我们需要对枪械的参数进行细分,让我们来看看射击游戏各种参数到底有多丰富:



这是一款吃鸡游戏的参数列表,各种参数组合就成了各种不同的枪械。在这里,我只用了最具代表性的五个射击参数和一个射击偏移物理参数,这些组合足够我们做出大部分的常规枪械了。


子弹的需求就不用这么细分了,仅仅需要速度、移动方向向量、存在时间,它最主要的功能就是处理移动和进行碰撞检查。


子弹算法原理



在游戏开发中,开枪射击有两种常规实现方式:射线检查和物理碰撞。


先说第一种射线检查思路。当射击后枪械指向方向会出一条射线,射线命中模型的点,就是击中点。我们在此基础上做出两种方案:



一是直接命中,没有子弹的事儿。这个方案完全不考虑速度问题,对于近距离射击是没问题的,但是远距离的话……如果想看到弹道,那就是不可能的。是在世界中产生一个子弹,依据发射点和命中点的距离、以及子弹的飞行速度计算一个插值运动,让飞行粒子沿着它飞到目标。但是这里有一个致命问题:如果子弹速度过慢,在它的弹道中间突然出现了物体,则无法击中物体。



第一种射线检查似乎不太完美,毕竟子弹命中目标和开火并不是同时发生的。那么第二种物理碰撞是否可行呢?



第二种物理碰撞,即子弹在飞行中碰到什么就是什么。但是碰撞在高速移动的物理世界中并不能简简单单地这么处理,因为游戏世界不是真实世界,可能会出现穿模、碰撞点和预期击中点不一致等情况。



似乎走到了死胡同。哪种方案更加合适呢?


最佳的处理方案是两者结合,准确来说就是各自取一部分。在开火的时候,我们仍然让子弹产生,并且按照预定的轨迹飞行。当然了,这个子弹可以可见,也可以不可见,通常为了游戏体验,我们都会弄一个粒子特效让飞行过程可见。那么在子弹飞行的过程中,要用物理碰撞检查吗?



其实不然,应该用射线检查。我们让子弹进行射线检查,而不是发射器发射出去的射线。子弹在飞行的时候,它的下一个点的轨迹是可以确定的,从当前帧的点到下一个帧的点,这就是一条射线,如果这条射线命中了任何符合条件的碰撞体,就可以判定是命中了。由于射线检查可以明确得到碰撞点信息,因此它完全可以作为下一帧的子弹命中点。有了这个思路,我们就可以按照它写代码了。


子弹代码


关于子弹组件的脚本代码,需要 speed、vector 变量作为计算处理。其中我们对 vector 做一下处理,在被赋值的时候,对速度进行一次计算,标记它在一个单位时间内应该走多远,这样做是为了避免额外的计算量。在 Update 里面加入向量移动,并且在移动之后检查下一帧是否会碰撞到任何刚体。我们写一个检查方法,按照前面说的原理,通过步长长度和向量的计算,引出一条射线,让它到物理世界中检查它前方是否有碰撞,如果有则处理碰撞逻辑。


BulletSc.ts 的代码:

import { _decorator, Component,  Vec3, v3, geometry, physics, RigidBody, game } from 'cc';
import { AutoRecycleSc } from './AutoRecycleSc';
import { ImpactHelperSc } from './ImpactHelperSc';
const { ccclass, property } = _decorator;

@ccclass('BulletSc')
export class BulletSc extends Component {

    private _speed: number = 200;
    public get speed(): number {
        return this._speed;
    }
    public set speed(v: number) {
        this._speed = v;
        if (this._vector) {
            this._vector = this._vector.normalize().multiplyScalar(this.speed);
        }
    }
    private _vector: Vec3 = null;
    setVector(v: Vec3) {
        this._vector = v.clone().multiplyScalar(this.speed);
        this.node.forward = v;
        BulletSc.preCheck(thisthis.speed / 60);
    }
    private _vec3 = v3();

    update(deltaTime: number) {
        if (this._vector) {
            Vec3.multiplyScalar(this._vec3, this._vector, deltaTime);
            this.node.position = this.node.position.add(this._vec3);
            BulletSc.preCheck(thisthis._vec3.length());
        }
    }
    private static preCheck(b: BulletSc, len: number) {
        const p = b.node.worldPosition;
        const v = b._vector;
        const ray = geometry.Ray.create(p.x, p.y, p.z, v.x, v.y, v.z);
        const phy = physics.PhysicsSystem.instance;
        if (phy.raycast(ray, 0xffffff, len)) {
            if (phy.raycastResults.length > 0) {
                let result = phy.raycastResults[0];    
                game.emit(ImpactHelperSc.AddImpactEvent,b,result);
                if (result.collider.getComponent(RigidBody)) {
                    result.collider.getComponent(RigidBody).applyForce(b._vector, result.hitPoint);
                }
                if (b.getComponent(AutoRecycleSc))
                    b.getComponent(AutoRecycleSc).recycle();
            }
        }
    }
}


处理碰撞判定的这个计算方法不是每个对象独有的,因此使用静态方法会是一个不错的选择。在设置向量的位置也要进行一次判定,这是因为有时候速度很快,它创建的时候在下一帧先进行了移动,直接飞到了很远的地方,这时再去检查可能就不对了,所以在子弹生成的瞬间就要进行判定,避免穿模。


另外我们再建立一个自动回收的脚本,将来可以挂在子弹、斑痕、弹壳等上面,通过一个延迟时间变量,在合适的时机自动回收掉物体。有了这个脚本,以后可以很方便地扩展出对象池回收站的功能,这里就不赘述了:

import { _decorator, Component, Node } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('AutoRecycleSc')
export class AutoRecycleSc extends Component {
    @property
    deltaTime = 5;

    update (dt: number) {
        this.deltaTime -= dt;
        if(this.deltaTime <=0){
            this.recycle();
        }
    }
    recycle(){
        this.deltaTime = 1000;
        this.node.destroy();
    }
}


枪械逻辑


枪械的组件脚本,利用 ccclass 制作一个配置项 GunOverView,包含枪械的概述(子弹速度、弹夹大小、射击速度、重填时间、同时子弹数、偏移震动的范围参数),通过外部引用属性,来获取到枪火特效、子弹发射点、子弹的预制体,这些是从场景或者项目中需要获得的对应的引用:

@ccclass("gun_overview")
export class GunOverView {
    @property
    bulletSpeed = 200;
    @property
    ammoPerMag: number = 10;
    @property
    timeBetweenShots: number = 0.3;
    @property
    timeReload: number = 1;
    @property
    meanwhile: number = 1;
    @property
    speadValue: number = 1;
}


三个变量来处理射击状态、计算缓存,一个计数器用来计算射击、子弹消耗、重填计时:

isShotting = true;
private vec3: Vec3 = v3();
private timer: Timer = new Timer();
// other class
class Timer {
    shot: number = 0;
    ammo: number = 0;
    reload: number = 0;
}


射击方法里,同时射出的子弹数量循环创建子弹,封装一下 createBullet:

createBullet() {
// to create bullet
}


枪口的朝向这个向量就是子弹要沿着飞行的向量。当子弹创建的同时,设置起始位置和速度,飞行向量则需要重新计算一下,因为我们还有一个重要的体验参数就是震动。按照角度随机将飞行向量做一下旋转,这里用向量变换和四元数相乘获得新的向量,新向量就是子弹的朝向方向,我们把它设置到子弹脚本里的向量即可:

// ... Part
@property(GunOverView)
gunOverview: GunOverView = new GunOverView();
@property(Node)
fireEffect: Node = null;
@property(Prefab)
bullet: Prefab = null;
@property(Node)
muzzleNode: Node = null;
// ... Part
createBullet() {     
    this.vec3 = this.muzzleNode.forward.clone();
    const b = instantiate(this.bullet);
    director.getScene().addChild(b);
    b.setWorldPosition(this.muzzleNode.worldPosition);
    b.setWorldRotation(this.muzzleNode.worldRotation);
    b.getComponent(BulletSc).speed = this.gunOverview.bulletSpeed;
    let rot = this._quat;
    const speadValue = this.gunOverview.speadValue;
    Quat.fromEuler(rot, (Math.random() * 2 - 1) * speadValue,
        (Math.random() * 2 - 1) * speadValue, (Math.random() * 2 - 1) * speadValue);
    Vec3.transformQuat(this.vec3,this.vec3.normalize(),rot);
    b.forward = this.vec3;
    b.getComponent(BulletSc).setVector(b.forward);
    b.worldScale = this.muzzleNode.worldScale;
}
// ... Part


Update 中计算计时器,按照射击条件发射,当子弹的数量足够的时候,计算射击冷却时间,产生发射行为,子弹随之消耗增加,当达到最大的时候触发 reload。整体的流程就是这样,其中很多代码可以提取出来,比如射击、创建子弹、重置状态等等:

update(dt:number){
    if(!this.isShotting)return;
    if(this.timer.ammo > 0){
        this.timer.shot += dt;
        if(this.timer.shot >= this.gunOverview.timeBetweenShots){
            this.timer.shot = 0;
            this.shot();
            this.timer.ammo -= 1;
            this.timer.reload = 0;
            if(this.timer.ammo <=0){
                //重填
            }
        }
    }else{
        if(this.timer.reload >= this.gunOverview.timeReload){
            this.timer.ammo = this.gunOverview.ammoPerMag;
            this.timer.reload = 0;
        }
        this.timer.reload += dt;
    }
}
resetState(){
    this.timer.shot = this.timer.ammo = this.timer.reload = 0;
}


射击方法里我们会尝试调用粒子系统,目前我用了一种遍历子节点的方式播放粒子特效,所以还要写一个粒子效果帮助者的类,这个帮助类可以在多个地方使用:

ParticleEffectHelper.ts

import { ParticleSystem,Node } from "cc";
export module ParticleEffectHelper{
    export function Play(node:Node){
        const arr = node.getComponentsInChildren(ParticleSystem);
        for(let a of arr){
            a.stop();
            a.play();
        }
    }
}


命中表现


正如前面所说,当命中的时候,我们可以获得碰撞点,在碰撞点的位置上生成斑痕特效,除此之外还需要依据碰撞面的法线,来确定生成面的朝向旋转。



为此,需要写一个命中点管理组件脚本,它的作用是为合适的碰撞点添加击中效果,比如命中墙壁之类的要处理斑痕、命中敌人就直接飙液体了。所以这个组件脚本,我们通过监听一个添加碰撞消息来处理碰撞事件,在事件接收参数中包含子弹信息和物理命中点的射线信息,在此计算和处理命中点的特效位置和朝向。射线命中测试中包含了命中法线信息,命中特效的朝向跟着法线指向即可,最终将生成的特效添加到目标物体上。现在回到子弹的脚本中,为它的命中时添加事件派发,告诉命中帮助脚本击中目标了:

ImpactHelperSc.ts

import { _decorator, Component, Node, Prefab, game, PhysicsRayResult, instantiate } from 'cc';
import { BulletSc } from './BulletSc';
const { ccclass, property } = _decorator;
 
@ccclass('ImpactHelperSc')
export class ImpactHelperSc extends Component {
    public static AddImpactEvent:string = "AddImpactEvent";
    @property(Prefab)
    impact1:Prefab = null;

    start () {
        game.on(ImpactHelperSc.AddImpactEvent,<any>this.onAddImpactEvent,this);
    }
    private onAddImpactEvent(b:BulletSc,e:PhysicsRayResult) {
        const impact = instantiate(this.impact1);
        impact.worldPosition = e.hitPoint.add(e.hitNormal.multiplyScalar(0.01));
        impact.forward=e.hitNormal.multiplyScalar(-1);
        impact.scale = b.node.scale;
        impact.setParent(e.collider.node,true);
        
    }
}


接着返回 Creator,简单拼接一下枪械射击点,然后放置一个墙面。由于 Creator 的默认场景不是很完美,我只得自己手动调整一下。制作一个简单的枪械发射器,模样看起来差不多就可以,再做一个子弹 Prefab,放上特效,并且挂子弹组件脚本和自动回收脚本,稍微修正一下。



将这个帮助脚本 ImpactHelperSc 添加到一个场景节点上,再把命中点的 prefab 添加给它引用项。



现在试试效果。给摄像机加入自由控制脚本,飞近一点看看如何(为了确认弹坑点位置和朝向的准确性,我弄了一个圆球)。可以看出命中点的特效还是很不错的,虽然和《守望先锋》有很大的差距,还有很多优化空间,但是已经提供了特效思路。



资源链接


视频版教程发布在 B 站:

https://www.bilibili.com/video/BV15u411S7Mz

完整源码见 Cocos Store:

https://store.cocos.com/app/detail/3473

Demo 地址:

https://forum.cocos.org/t/topic/127488



本次的分享就到这里,感谢阅读!如果您喜欢这篇文章,欢迎移步 B 站和 Cocos 论坛支持一下或和我交流!我是 Nowpaper ,一个混迹游戏行业的老爸,喜欢研究各种有趣的玩法,感兴趣请点击:


往期精彩

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存